feat(workhub): add work identity, prompt status, and keyboard selection - #5198
feat(workhub): add work identity, prompt status, and keyboard selection#5198ARE404 wants to merge 11 commits into
Conversation
Generated-by: Codex
Generated-by: Codex
Generated-by: Codex
Generated-by: Codex
Generated-by: Codex
Generated-by: Codex
Generated-by: Codex
Preserve message decorations and question fixtures with the renderer-owned transcript window. Disambiguate repeated work navigation labels with prompt context. Generated-by: Codex
Move the keyboard hint translations into ConversationCopy to satisfy the locale hygiene CI gate without changing displayed copy. Generated-by: Codex
Reserve the same right-side accent gutter for every WorkHub prompt so unlinked and delegated messages share a metadata edge. Verify the shared edge in the mixed-work story. Generated-by: Codex
Astro-Han
left a comment
There was a problem hiding this comment.
Design verdict. Target selection is decided at the right authority: the Host keeps the pending offer (per-Turn, 64 entries, 10 min TTL), re-validates the opaque candidateRef against a fresh candidate set, binds the durable routingDecision before admission, and the renderer only echoes requestId + candidateRef. Loss of the offer (Host restart, expiry, removed target, second prompt) degrades to "ask again", which is fine for unadmitted draft state. But the pause is signalled by throwing TargetSelectionRequired out of prepareWorkHubRoutingDecision / prepareFreshContent, through RootTurnCoordinator.startRootMessage, and catching it back in the coordinator. startRootMessage runs under runCommand, which treats any non-whitelisted error as an authority failure and drains the Host. The selector therefore takes the Runtime Host down on its first use. Not merge-ready; the fix is local in shape (return a value instead of throwing) but must go through the root authority's outcome type.
P0 — Every ambiguous prompt drains the Runtime Host
Reachability ①: user sends a prompt the routing model classifies clarify.
packages/runtime-host/src/server/workhub-coordination-coordinator.ts:808,898throwTargetSelectionRequired;:919,923,927throw plainErrorfrom#selectedRoutingDecision, which runs insideprepareFreshContent.- Both run inside
startRootMessage's admission task (packages/runtime-host/src/server/root-turn-coordinator.ts:2032-2155), which is wrapped byrunCommand(:3347-3360): anything that is notRuntimeHostedRootConflictError/RuntimeHostedRootUnavailableError/HostedRootAdmissionGateError/ shutdown-cancel callsthis.requestHostDrain()before rethrowing. packages/runtime-host/src/server/host-kernel.ts:356-364then sets#shutdownRequested, arms the shutdown deadline and begins the composition drain; every later operation, including the selection answer, is refused withhost_draining(:622).
Repro (ran locally through the real RootTurnCoordinator, then removed): append to root-turn-coordinator.test.ts a createFailureFixture({ prepareWorkHubRoutingDecision: async () => { throw new Error('WorkHub target selection required'); } }), create the coordination stable session, call startWorkHubCoordinationMessage(...). It rejects and fixture.drainRequested() === true; no admission is written. The PR's coordinator test cannot see this because its startWorkHubCoordinationMessage fixture bypasses RootTurnCoordinator.
Fix: make the pause a value, not an exception. prepareWorkHubRoutingDecision returns { kind: 'decision', decision } | { kind: 'target_selection', request }; prepareFreshWorkHubExecution propagates it and startRootMessage returns completedStart(...) with a dedicated outcome (e.g. ok: false, code: 'target_selection_required', request) that #answer maps to targetSelection. #selectedRoutingDecision returns { kind: 'rejected', outcome: turnFailure('operation_conflict', …) } through prepareFreshContent instead of throwing (today a mismatched candidateRef also surfaces as internal_failure + console.error, operation-dispatcher.ts:369-377). Add the real-authority test above with drainRequested() === false.
P1 — Intent-unclear prompts can no longer reach the assistant
Reachability ①: any prompt whose intent the model cannot classify ("hi", "帮我看看这个").
applyWorkHubRoutingPolicy maps intent.kind === 'unclear' to clarify (packages/core/src/workhub-routing.ts:127). The coordinator turns every model clarify into a selector when allowTargetSelection (workhub-coordination-coordinator.ts:866-878), and startRootMessage always passes true (root-turn-coordinator.ts:2096). The selector offers only existing candidates, create_new, or "continue explaining", which returns the draft unsent (apps/desktop/src/renderer/features/workhub/ui/workhub-target-selector.tsx:44-54, use-workhub-controller.ts:186-198). On main a clarify Turn is admitted and the assistant asks in conversation; now "Which work do you want to continue?" is shown for prompts that are not about continuing work, and there is no way to deliver them. The test asserts this ('intent clarification also waits before admission').
Fix (smallest): add a third selection { kind: 'answer_here' } that admits with { disposition: 'answer_here' }, and make the "none of these" button send it instead of dismissing. Alternative: let the policy distinguish target ambiguity from intent ambiguity and only pause on the former.
P2 — New text and keycaps are hand-rolled instead of Astryx roles
Reachability ① (every rendered label). RadioList, Button and TextInput are used correctly; the surrounding text is not. packages/ui/src/choice-panel.tsx:83 renders a raw <kbd className="maka-choice-shortcut"> where Astryx exports Kbd; the hint at :85, workhub-selection-hint and workhub-delegation-status are <p>/<span> with font-size: 11px/12px literals (workhub.css:281,307, packages/ui/src/styles.css:1147,1149), giving 15.7px/17.1px line boxes off the 4px grid. The Workspace / Session label is a Button variant="ghost" restyled by CSS to height: auto; padding: 0; font-size: 11px (workhub.css:275-281) — fighting the primitive to get a text-sized navigation control. DESIGN.md §7 "Role, Not Axes" and §9 "Use Astryx primitives as the default seam"; the settings pages already use <Text type="supporting" color="secondary"> 68 times. Fix: Kbd keys="1" for shortcuts; Text type="supporting" color="secondary" for hint and status; Link (no href renders a <button>) with type="supporting" for the identity label, keeping only the --workhub-work-hue override since Astryx has no per-hue variant. That removes the three CSS literals and the ghost-button override.
Notes (not blocking)
- AskUserQuestion vs. selector. Two composer-area choice surfaces now exist with different plumbing (Host-transient offer vs. interaction authority). Keeping target selection before admission is justified because the routing decision must be durable before the model runs; say so in the PR body so the split does not look accidental.
- Epoch. 143 is correct against current
main(142). #5164 also bumps 142→144; whichever merges second must re-bump. - Prompt status noise (P3).
workhub-root.tsx:85gives every historicalturn_statea status, so every completed prompt reads "Completed · time" (verified in DOM forproduct-workhub--question-lifecycleand--colored-work-history). Only non-terminal, failed or aborted states change what the user does next; consider droppingcompletedfor plain coordination turns. - PNGs under
docs/images/pr/(P3). No tracked rule forbids them, but CONTRIBUTING.md:87 asks for screenshots in the PR, and theraw.githubusercontent.com/ARE404/...links die with the branch. Attach to the PR and drop the files. - Scope. The unchecked real-model flow matters for merge only for AskUserQuestion under the
bypasscoordination session; the selector itself is fully exercised without a model once the P0 path is fixed.
Verified: @maka/runtime-host coordinator/protocol/tool-profile tests (41 pass); @maka/desktop workhub-send-visibility + workhub-anchor-rail (26 pass); both new tests go red with the fix reverted; storybook-static computed styles for product-workhub--colored-work-history, --target-selection, --question-lifecycle, product-ask-user-question--keyboard-choices in light and dark (accent bars 3px on the sender edge, labels oklch(0.42 0.075 h) / oklch(0.8 0.09 h), status before timestamp); real key presses select radios in both stories.
中文
设计结论。 目标选择的决定权放在了正确的权威上:Host 持有待选 offer(按 Turn,64 条,10 分钟 TTL),用最新候选集重新校验不透明的 candidateRef,在 admission 前绑定持久的 routingDecision,renderer 只回传 requestId + candidateRef。offer 丢失(Host 重启、过期、目标被删、第二条 prompt)都退化为"再问一次",对未 admit 的草稿态是合理的。但暂停是通过从 prepareWorkHubRoutingDecision / prepareFreshContent 抛出 TargetSelectionRequired、穿过 RootTurnCoordinator.startRootMessage、再在 coordinator 里 catch 回来实现的。startRootMessage 跑在 runCommand 里,任何不在白名单的异常都会被当作权威故障并触发 Host drain。所以选择器第一次使用就会把 Runtime Host 拉下线。不可合并;修法形态上是局部的(返回值而不是抛异常),但必须经由根权威的 outcome 类型。
P0 — 每条歧义 prompt 都会 drain Runtime Host
可达性 ①:用户发送一条被路由模型判为 clarify 的 prompt。
workhub-coordination-coordinator.ts:808,898抛出TargetSelectionRequired;:919,923,927在#selectedRoutingDecision里抛普通Error,而它在prepareFreshContent内执行。- 两者都在
startRootMessage的 admission task 内(root-turn-coordinator.ts:2032-2155),外层是runCommand(:3347-3360):不是RuntimeHostedRootConflictError/RuntimeHostedRootUnavailableError/HostedRootAdmissionGateError/ shutdown-cancel 的异常都会先this.requestHostDrain()再重抛。 host-kernel.ts:356-364随即置位#shutdownRequested、启动关机 deadline、开始 composition drain;之后所有操作(包括选择后的 answer)都被拒为host_draining(:622)。
复现(已在本地经真实 RootTurnCoordinator 跑过,随后删除):在 root-turn-coordinator.test.ts 末尾追加 createFailureFixture({ prepareWorkHubRoutingDecision: async () => { throw new Error('WorkHub target selection required'); } }),创建 coordination stable session,调用 startWorkHubCoordinationMessage(...)。结果 reject,且 fixture.drainRequested() === true,没有写入 admission。PR 里的 coordinator 测试看不到这一点,因为其 startWorkHubCoordinationMessage 夹具绕过了 RootTurnCoordinator。
修法:把暂停做成返回值而非异常。prepareWorkHubRoutingDecision 返回 { kind: 'decision', decision } | { kind: 'target_selection', request };prepareFreshWorkHubExecution 透传,startRootMessage 用专门的 outcome(如 ok: false, code: 'target_selection_required', request)completedStart(...),由 #answer 映射为 targetSelection。#selectedRoutingDecision 通过 prepareFreshContent 返回 { kind: 'rejected', outcome: turnFailure('operation_conflict', …) } 而不是抛(现在 candidateRef 不匹配也会变成 internal_failure + console.error,operation-dispatcher.ts:369-377)。补上面这个真实权威的测试并断言 drainRequested() === false。
P1 — 意图不明的 prompt 再也到不了助手
可达性 ①:任何模型无法归类意图的 prompt("hi"、"帮我看看这个")。
applyWorkHubRoutingPolicy 把 intent.kind === 'unclear' 映射为 clarify(workhub-routing.ts:127)。coordinator 在 allowTargetSelection 时把所有模型 clarify 都变成选择器(workhub-coordination-coordinator.ts:866-878),而 startRootMessage 总是传 true(root-turn-coordinator.ts:2096)。选择器只提供现有候选、create_new 或"继续说明"——后者只是把草稿退回不发送(workhub-target-selector.tsx:46-58,use-workhub-controller.ts:192-201)。main 上 clarify 会 admit Turn,由助手在对话里追问;现在对与"继续哪个工作"无关的 prompt 也弹出该问题,且没有任何投递路径。测试还专门断言了这一行为('intent clarification also waits before admission')。
修法(最小):增加第三种 selection { kind: 'answer_here' },以 { disposition: 'answer_here' } admit,并让"都不是"按钮发送它而不是 dismiss。替代方案:让 policy 区分目标歧义和意图歧义,只在前者暂停。
P2 — 新增文字和快捷键帽是手写的,不是 Astryx 角色
可达 ①(每个渲染的标签)。RadioList、Button、TextInput 用得对,周围的文字没有。packages/ui/src/choice-panel.tsx:83 手写 <kbd className="maka-choice-shortcut">,而 Astryx 有 Kbd;:85 的提示、workhub-selection-hint、workhub-delegation-status 都是带 font-size: 11px/12px 字面量的 <p>/<span>(workhub.css:281,307,packages/ui/src/styles.css:1147,1149),行框 15.7px/17.1px,不在 4px 网格上。Workspace / Session 标签是被 CSS 改成 height: auto; padding: 0; font-size: 11px 的 Button variant="ghost"(workhub.css:275-281),为得到文字尺寸的导航控件而对抗原语。DESIGN.md §7 "Role, Not Axes"、§9 "Use Astryx primitives as the default seam";settings 页已用 <Text type="supporting" color="secondary"> 68 次。修法:快捷键用 Kbd keys="1";提示和状态用 Text type="supporting" color="secondary";身份标签用 Link(无 href 时渲染 <button>)加 type="supporting",只保留 --workhub-work-hue 覆盖(Astryx 没有按色相的变体)。这样三处 CSS 字面量和 ghost 按钮覆盖都可删。
备注(不阻塞)
- AskUserQuestion 与选择器。 composer 区域现在有两套选择界面、两套管线(Host 临时 offer vs. interaction authority)。把目标选择放在 admission 前是有理由的(路由决定必须在模型运行前持久化),请在 PR 正文写明,避免看起来像是偶然分裂。
- Epoch。 相对当前
main(142),143 正确。#5164 也从 142 升到 144;后合并者需要再升。 - Prompt 状态噪音(P3)。
workhub-root.tsx:85给每条历史turn_state都赋状态,于是每条已完成的 prompt 都显示"已完成 · 时间"(在product-workhub--question-lifecycle与--colored-work-history的 DOM 中确认)。只有非终态、失败或中止会改变用户的下一步;建议普通 coordination turn 不显示completed。 docs/images/pr/下的 PNG(P3)。 没有仓库规则禁止,但 CONTRIBUTING.md:87 要求截图放在 PR 里,且raw.githubusercontent.com/ARE404/...链接会随分支删除失效。改为 PR 附件并删掉文件。- 范围。 未勾选的真实模型流程只对
bypass模式 coordination session 下的 AskUserQuestion 有合并意义;选择器本身在 P0 修复后无需模型即可完整验证。
已验证:@maka/runtime-host coordinator/protocol/tool-profile 测试(41 通过);@maka/desktop workhub-send-visibility + workhub-anchor-rail(26 通过);两个新测试在还原修复后均变红;storybook-static 在 light/dark 下对 product-workhub--colored-work-history、--target-selection、--question-lifecycle、product-ask-user-question--keyboard-choices 的计算样式(发送方边缘 3px 色条、标签 oklch(0.42 0.075 h) / oklch(0.8 0.09 h)、状态在时间戳之前);真实按键在两个 story 中都能选中单选项。
|
|
||
| .workhub-target-selector { width: min(var(--maka-reading-measure), calc(100% - 2 * var(--space-6))); margin-inline: auto; } | ||
| .workhub-selection-hint { margin: 4px 0 0; color: var(--muted-foreground); font-size: 12px; } | ||
| .workhub-target-selector [role="radiogroup"] { max-height: 260px; overflow-y: auto; } |
There was a problem hiding this comment.
--workhub-selection-label: 0.42 0.075 / 0.8 0.09 duplicates the values of --workhub-work-label (lines 102/108). Set --workhub-work-hue on each item and reuse .workhub-work-identity so the identity colour has one authority.
与 --workhub-work-label 数值重复,复用现有变量即可。
| onEscape(): void; | ||
| children?: ReactNode; | ||
| }) { | ||
| const locale = useUiLocale(); |
There was a problem hiding this comment.
Locale strings are inlined in the component; the rest of packages/ui reads copy through getConversationCopy(locale) (see user-question-prompt.tsx). Move the hint there so zh-TW/en/zh-CN are maintained in one table.
文案应进 getConversationCopy 表,而不是组件内三元。
| const COORDINATION_TOOL_PROFILE = 'workhub-coordination-v2' as const; | ||
| const COORDINATION_PERMISSION_MODE = 'bypass' as const; | ||
| type TargetSelectionRequest = | ||
| import('../protocol/workhub-coordination.js').WorkHubTargetSelectionRequest; |
There was a problem hiding this comment.
Inline import('../protocol/workhub-coordination.js') types here and at lines 810 and 884 — the file already imports from that module at the top; add WorkHubTargetSelectionRequest / WorkHubCoordinationCandidatesResult to that import.
类型改为顶部 import,不用内联 import()。
| return this.#requireTargetSelection(input.turnId, contentDigest, current.result); | ||
| } | ||
| if (pending.digest !== contentDigest) | ||
| throw new Error('WorkHub selection belongs to a different request'); |
There was a problem hiding this comment.
Plain throw new Error(...) here and at 923/927 reaches the dispatcher as internal_failure with a console error for what is a client mismatch. Return { kind: 'rejected', outcome: turnFailure('operation_conflict', …) } through prepareFreshContent instead (this also disappears with the P0 fix, which must stop throwing through startRootMessage).
客户端不匹配不应以异常穿透到 dispatcher,改为 operation_conflict outcome。
| content: MessageContent, | ||
| execution: RootExecutionDescriptor, | ||
| inputClosedSignal?: AbortSignal, | ||
| allowTargetSelection = false, |
There was a problem hiding this comment.
allowTargetSelection has one caller and it always passes true (line 2096); the flag exists only so the coordinator test fixture can toggle it. Drop the parameter or gate on something real.
allowTargetSelection 唯一调用点恒为 true,参数可删。
| const [editingProgressRequest, setEditingProgressRequest] = useState<number>(); | ||
| const [expandedOverride, setConversationExpanded] = useState<boolean>(); | ||
| const promptStates = new Map<string, import('../model/linked-work.js').WorkHubDelegationState>(); | ||
| for (const message of transcript.messages) if (message.type === 'turn_state') promptStates.set(message.turnId, message.status); |
There was a problem hiding this comment.
Every historical turn_state gets a status, so completed prompts render "Completed · time" forever (verified in product-workhub--question-lifecycle). A terminal completed on a plain coordination turn changes nothing the user does next; keep running / waiting_for_user / failed / aborted and delegation states only.
每条已完成 prompt 都显示"已完成",是噪音;只保留非终态和失败/中止。
| play: async ({ canvasElement }) => { | ||
| const canvas = within(canvasElement); | ||
| await userEvent.keyboard('2'); | ||
| expect(canvas.getByRole('radio', { name: '公开测试' })).toBeChecked(); |
There was a problem hiding this comment.
expect(...).toBeChecked() immediately after userEvent.keyboard('2') fails in the static build in both colour schemes (React has not committed yet); real key presses do select the radio. Wrap the assertions in waitFor, here and in workhub.stories.tsx:242,252,254,284.
断言紧跟按键会在静态构建里失败,包一层 waitFor。
| value: candidate.candidateRef, | ||
| label: candidate.sessionName, | ||
| accentColor: `oklch(var(--workhub-selection-label) ${workHubIdentityHue(candidate.sessionId)})`, | ||
| description: `${candidate.workspace.hostCwd.replace(/[/\\]+$/, '').split(/[/\\]/).at(-1) ?? ''} · ${presentSessionStatus(candidate.state, locale).label}`, |
There was a problem hiding this comment.
hostCwd.replace(/[/\\]+$/, '').split(/[/\\]/).at(-1) is the same expression as linked-work.ts:64; one workspaceNameFromCwd helper in model/ keeps Windows/POSIX handling in one place.
与 linked-work.ts 的路径取名表达式重复,抽一个 helper。
Generated-by: Codex
Astro-Han
left a comment
There was a problem hiding this comment.
Status check on 9f31d58fa. Three commits landed since the last round; none touches packages/runtime-host or packages/core, so the P0 and P1 stand unchanged. 76639a86a resolves one inline item. bb8bf7ad0 is a new alignment tweak. 9f31d58fa adds a conversation filter that is not in the PR title, body, or any issue; as fresh code it has a P1 of its own. Still not merge-ready.
| Item | Status on 9f31d58fa |
|---|---|
| P0 — ambiguous prompt drains the Host | Open. Re-ran the probe through the real RootTurnCoordinator (createFailureFixture({ clientCapabilities, prepareWorkHubRoutingDecision: async () => { throw new Error('WorkHub target selection required') } }), v2 coordination Session, startWorkHubCoordinationMessage): routing called once, rejects with that error, drainRequested() === true, no admission written. workhub-coordination-coordinator.ts:82,808,896 and root-turn-coordinator.ts:3347-3360 are byte-identical to the reviewed head. |
| P1 — intent-unclear prompts cannot reach the assistant | Open. workhub-coordination-coordinator.ts:866-878 still maps every model clarify to a selector when allowTargetSelection; root-turn-coordinator.ts:2096 still passes true; the selector still has no "answer here" delivery. |
| P2 — hand-rolled text/keycaps | Open, and grown. choice-panel.tsx:72 still renders <kbd className="maka-choice-shortcut">; workhub.css:306 (.workhub-selection-hint, 12px) and :279-283 (ghost Button forced to text size) unchanged. 9f31d58fa adds .workhub-conversation-filter { font-size: 12px } (workhub.css:325, computed line box 17.1px) in the same style. |
Inline 04 — --workhub-selection-label duplicates --workhub-work-label |
Open (workhub.css:317-318). |
Inline 05 — hint strings inline in ChoicePanel |
Addressed by 76639a86a: questions.keyboardHint in conversation-copy.ts, read via getConversationCopy(locale). |
Inline 06 — inline import() types in the coordinator |
Open (:81,810,884). |
Inline 07 — throw new Error for client mismatch in #selectedRoutingDecision |
Open (:919,923,927); folds into the P0 fix. |
Inline 08 — allowTargetSelection always true |
Open (root-turn-coordinator.ts:1569,2096). |
| Inline 09 — every completed prompt shows "Completed · time" | Open (workhub-root.tsx:85). |
Inline 10 — toBeChecked() without waitFor |
Open at the same lines; note the four stories passed in this static build (light), so the flake is timing-dependent, which is the reason to wrap them. |
Inline 11 — duplicated hostCwd basename expression |
Open (workhub-target-selector.tsx:49, linked-work.ts:64). |
Notes (PNGs in docs/images/pr/, epoch, status noise) |
PNGs still in the tree. Epoch 143 vs origin/main 142 is still correct. |
bb8bf7ad0 gives every WorkHub prompt a transparent 3px end border so unlinked and delegated prompts share a metadata edge, with a story assertion; no concerns.
9f31d58 — conversation filter: does not belong in this PR
Design. The commit adds a second interaction model on top of the identity rails: double-click / shift-click on a navigation item filters the conversation, a single click now opens the Session only after a 500 ms timer, and every linked prompt and answer grows an invisible 12 px <button> on its accent edge. None of this is in the PR summary, the verification list, or an issue. It lands mid-review on a PR that already carries an open P0, and it changes the primary rail gesture for everyone. Split it out and open it against a short design note (which gesture, why a hidden edge button when the visible label already opens the Work), after P0/P1 are fixed here.
P1 — Sending while filtered makes the user's own prompt disappear
Reachability ①: filter by any Work (edge button or rail double-click), type a follow-up, press Enter.
workhub-conversation.tsx:120-121,133 keep only messages, liveTurn, and transient messages whose turnId is in matchingTurns, and matchingTurns comes from workLinks, which linked-work.ts:64-96 derives only from durable delegation_assigned / tasks tool results. A new Turn has no link until delegation lands, so the optimistic bubble, the admitted prompt, the streaming answer, and the running status are all hidden; runningStatus is forced false (:134). Reproduced on product-workhub--colored-work-history in the static build: filter to 支付回调幂等性, send FILTERED_SEND_PROBE: transcript stays at 2 turns, no transient row, no running status, filter bar still shown; clearing the filter reveals 5 turns including the new prompt and answer. For a clarify/answer_here Turn the message never appears until the user clears the filter by hand.
Fix (smallest): clear selectedWork on send (highlight.selectWork(undefined) in the send path, or in the controller's send), so the transcript returns to the full view exactly when new content is about to arrive. If the filter is meant to survive a send, the pending Turn and the live Turn must be exempt from the filter until their link exists.
P2 — Single click on the navigation rail now waits 500 ms
Reachability ①: every click on a rail item. workhub-navigation-rail.tsx:141 defers onOpenSession behind setTimeout(..., 500) so a double-click can be distinguished; on main the same click opens immediately. Measured in the static build: a single click resolves nothing for 500 ms. The primary action of the rail should not pay for a secondary gesture; keyboard-only users already get the immediate path (event.detail === 0). If the filter stays, put it on a modifier or a distinct control and keep single click immediate.
P2 — Hidden 12 px edge buttons in the tab order
Reachability ①. Each linked prompt and answer renders .workhub-message-rail (chat-turn.tsx:575,645,675; workhub.css:321-323): 12 px wide, transparent, no text, tabIndex 0, positioned over the accent border. In product-workhub--colored-work-history that is six extra focus stops between the rail and the composer, each right before a visible identity button that already opens the same Work. Nothing on screen tells a pointer user the edge is clickable except the title. DESIGN.md's rule that an element must change the user's next action is not met; if a per-message filter entry is wanted, add it as a visible action on the existing label (MoreMenu or a second Button), not as an invisible hit area.
中文
针对 9f31d58fa 的状态核对。上一轮之后新增三个 commit,均未触及 packages/runtime-host 或 packages/core,因此 P0 与 P1 原样保留。76639a86a 解决了一条 inline 意见;bb8bf7ad0 是新的对齐微调;9f31d58fa 新增了对话筛选,PR 标题、正文和任何 issue 都没有提到,作为新代码它自身带来一个 P1。仍不可合并。
| 项目 | 在 9f31d58fa 上的状态 |
|---|---|
| P0 — 歧义 prompt 会 drain Host | 未修。 经真实 RootTurnCoordinator 重跑探针(createFailureFixture({ clientCapabilities, prepareWorkHubRoutingDecision: async () => { throw new Error('WorkHub target selection required') } }),v2 coordination Session,startWorkHubCoordinationMessage):routing 被调用一次,以该错误 reject,drainRequested() === true,未写入 admission。workhub-coordination-coordinator.ts:82,808,896 与 root-turn-coordinator.ts:3347-3360 与上次评审的 head 逐字节相同。 |
| P1 — 意图不明的 prompt 到不了助手 | 未修。 workhub-coordination-coordinator.ts:866-878 仍在 allowTargetSelection 时把所有模型 clarify 变成选择器;root-turn-coordinator.ts:2096 仍传 true;选择器仍没有"就在这里回答"的投递路径。 |
| P2 — 手写文字/快捷键帽 | 未修,且扩大。 choice-panel.tsx:72 仍是 <kbd className="maka-choice-shortcut">;workhub.css:306(.workhub-selection-hint,12px)与 :279-283(ghost Button 被压成文字尺寸)未变。9f31d58fa 又以同样方式加了 .workhub-conversation-filter { font-size: 12px }(workhub.css:325,计算行框 17.1px)。 |
Inline 04 — --workhub-selection-label 与 --workhub-work-label 重复 |
未修(workhub.css:317-318)。 |
Inline 05 — ChoicePanel 内联文案 |
已修(76639a86a):conversation-copy.ts 增加 questions.keyboardHint,经 getConversationCopy(locale) 读取。 |
Inline 06 — coordinator 内联 import() 类型 |
未修(:81,810,884)。 |
Inline 07 — #selectedRoutingDecision 对客户端不匹配 throw new Error |
未修(:919,923,927);随 P0 修法一起消失。 |
Inline 08 — allowTargetSelection 恒为 true |
未修(root-turn-coordinator.ts:1569,2096)。 |
| Inline 09 — 每条已完成 prompt 显示"已完成 · 时间" | 未修(workhub-root.tsx:85)。 |
Inline 10 — toBeChecked() 没包 waitFor |
未修,行号不变;本次静态构建(light)四个 story 都通过,说明是时序相关的 flake,这正是要包 waitFor 的原因。 |
Inline 11 — hostCwd 取名表达式重复 |
未修(workhub-target-selector.tsx:49,linked-work.ts:64)。 |
备注(docs/images/pr/ 的 PNG、epoch、状态噪音) |
PNG 仍在仓库里。epoch 143 相对 origin/main 的 142 仍正确。 |
bb8bf7ad0 给每条 WorkHub prompt 加 3px 透明尾边框,让未链接和已委派的 prompt 共用一条元数据边缘,并加了 story 断言;没有问题。
9f31d58 — 对话筛选:不属于本 PR
设计。该 commit 在身份色条之上叠加了第二套交互模型:导航项双击/shift 点击筛选对话,单击改为 500 ms 定时后才打开 Session,每条已链接的 prompt 和回答在色条边缘多出一个不可见的 12px <button>。这些都不在 PR 摘要、验证清单或任何 issue 里。它在评审进行中落到一个仍有 P0 未修的 PR 上,还改变了所有人的主手势。建议拆出去,先修好这里的 P0/P1,再附一段简短设计说明(选哪种手势、可见标签已能打开 Work 为何还要隐藏边缘按钮)单独开 PR。
P1 — 筛选状态下发送,用户自己的 prompt 会消失
可达性 ①:按任一 Work 筛选(边缘按钮或导航栏双击),输入后续内容,回车。
workhub-conversation.tsx:120-121,133 只保留 turnId 在 matchingTurns 中的消息、liveTurn 和 transient 消息,而 matchingTurns 来自 workLinks,后者由 linked-work.ts:64-96 仅从持久化的 delegation_assigned / tasks 工具结果推导。新 Turn 在委派落地前没有链接,于是乐观气泡、已 admit 的 prompt、流式回答、运行状态全部被隐藏;runningStatus 被强制为 false(:134)。已在静态构建的 product-workhub--colored-work-history 上复现:筛选到 支付回调幂等性,发送 FILTERED_SEND_PROBE:transcript 仍是 2 个 turn,没有 transient 行,没有运行状态,筛选条仍在;清除筛选后出现 5 个 turn,包含新 prompt 和回答。对 clarify/answer_here 的 Turn,消息在用户手动清除筛选前永远不出现。
修法(最小):发送时清除 selectedWork(在发送路径或 controller 的 send 里调 highlight.selectWork(undefined)),让 transcript 恰在新内容到来时回到完整视图。如果筛选必须跨发送保留,则 pending Turn 和 live Turn 在链接存在前必须豁免筛选。
P2 — 导航栏单击要等 500 ms
可达性 ①:每次点击导航项。workhub-navigation-rail.tsx:141 把 onOpenSession 推迟到 setTimeout(..., 500) 之后以区分双击;main 上同一次点击立即打开。静态构建实测:单击 500 ms 内没有任何反应。主动作不该为次要手势买单;键盘用户已经走即时路径(event.detail === 0)。若保留筛选,请放到修饰键或独立控件上,单击保持即时。
P2 — tab 顺序里的隐藏 12px 边缘按钮
可达性 ①。每条已链接的 prompt 和回答渲染 .workhub-message-rail(chat-turn.tsx:575,645,675;workhub.css:321-323):12px 宽、透明、无文字、tabIndex 0,覆盖在色条上。在 product-workhub--colored-work-history 里,这在导航栏与 composer 之间多出六个焦点停靠点,每个都紧挨着一个已经能打开同一 Work 的可见身份按钮。除了 title,屏幕上没有任何提示告诉指针用户边缘可点。不满足 DESIGN.md"元素须改变用户下一步动作"的规则;若需要按消息的筛选入口,应作为现有标签上的可见动作(MoreMenu 或第二个 Button),而不是不可见热区。
| await waitFor(() => expect(canvas.getByText('继续补充异常场景。')).toBeInTheDocument()); | ||
| await userEvent.click(canvasElement.querySelector('.maka-user-message .workhub-message-rail') as HTMLElement); | ||
| const older = canvas.queryByRole('button', { name: '更早的历史' }); | ||
| if (older) await userEvent.click(older); |
There was a problem hiding this comment.
if (older) await userEvent.click(older) makes the step optional, so the story passes whether or not the filter bar exposes "更早的历史"; the paged fixture guarantees hasOlder, so assert the button exists and click it unconditionally.
条件步骤让断言失去意义;这里 hasOlder 必为 true,直接断言按钮存在再点击。
| await waitFor(() => expect(canvasElement.querySelectorAll('.maka-transcript-turn')).toHaveLength(4)); | ||
| }, | ||
| }; | ||
| export const FilterWorkConversationsNarrow: Story = { ...FilterWorkConversations }; |
There was a problem hiding this comment.
FilterWorkConversationsNarrow is a byte-for-byte copy of FilterWorkConversations with no parameters.viewport, so it runs the same play at the same width (compare FullConversationNarrow at line 154). Add the tablet viewport or delete the story.
没有设置窄视口,和原 story 完全相同;补 viewport 或删掉。
| {chat.hasNewerHistory && <Button variant="ghost" label={copy.newerConversations} isDisabled={loadingHistory} onClick={() => void loadHistory('newer')} />} | ||
| {historyError && <span role="alert">{copy.controlFailed}</span>} | ||
| </div>} | ||
| <ChatView key={selected?.sessionId ?? "all"} {...chat} |
There was a problem hiding this comment.
key={selected?.sessionId ?? "all"} remounts ChatView on every filter change, discarding scroll position, expanded tool calls, and the reading anchor, and re-running onRetainWindow from an empty state when the filter clears. If the remount is only there to reset scroll, pass a scrollTargetTurn instead.
每次切换筛选都重挂 ChatView,丢滚动位置和展开状态;如只为重置滚动,用 scrollTargetTurn。
Summary
WorkHub conversations now identify delegated work with separate message accents (prompt on the right, answer on the left), compact colored Workspace / Session labels, and status text before the prompt timestamp. The standalone delegation result card is removed.
Ambiguous routing can pause before Turn admission and show a target selector in the composer area. Nothing is preselected: number keys and arrows select, Enter confirms, and Esc returns to the preserved draft. The Host validates the offered opaque candidate identity, refreshes the same target against current candidates, and leaves execution authorization with the existing Action Gate. Expired or unavailable targets require another selection.
WorkHub also exposes
AskUserQuestionthrough its tool profile and renders the shared question panel, including pending-question hydration, answer retry, and Stop. Prompt statuses cover coordination before delegation as well as delegated work. Ordinary question panels share keyboard selection and display submission errors without discarding answers.Verification
Conversation identity and timestamp status (captured during this branch's status implementation):
Target selection, waiting status, and keyboard hints (latest implementation, scripted native Electron fixture):
Remaining acceptance
Protocol compatibility
Runtime Host compatibility epoch increases from 142 to 143 because an answer response can now request target selection without admitting a Turn. Pending selector offers are bounded transient Host state; admitted routing decisions use the existing durable execution record.
AI use
Tool(s) and scope: Codex implemented the changes, tests, and PR description. Affected commits include
Generated-by: Codex; retain this trailer when squashing.Checklist
Does this PR entail a change in behavior?